<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>语音识别测试</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
            background-color: #f5f5f5;
        }
        .container {
            background: white;
            padding: 30px;
            border-radius: 10px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        h1 {
            color: #333;
            text-align: center;
        }
        .controls {
            text-align: center;
            margin: 20px 0;
        }
        button {
            padding: 10px 20px;
            margin: 5px;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            font-size: 16px;
        }
        .start-btn {
            background-color: #4CAF50;
            color: white;
        }
        .stop-btn {
            background-color: #f44336;
            color: white;
        }
        .connect-btn {
            background-color: #2196F3;
            color: white;
        }
        .status {
            padding: 10px;
            margin: 10px 0;
            border-radius: 5px;
            text-align: center;
        }
        .status.connected {
            background-color: #d4edda;
            color: #155724;
        }
        .status.disconnected {
            background-color: #f8d7da;
            color: #721c24;
        }
        .results {
            margin-top: 20px;
        }
        .result-item {
            padding: 10px;
            margin: 5px 0;
            border-radius: 5px;
            border-left: 4px solid #2196F3;
            background-color: #f8f9fa;
        }
        .result-item.final {
            border-left-color: #4CAF50;
            background-color: #e8f5e8;
        }
        .timestamp {
            font-size: 12px;
            color: #666;
        }
        .log {
            background-color: #f8f9fa;
            border: 1px solid #dee2e6;
            border-radius: 5px;
            padding: 10px;
            height: 200px;
            overflow-y: auto;
            font-family: monospace;
            font-size: 12px;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>WebSocket 语音识别测试</h1>
        
        <div class="controls">
            <button id="connectBtn" class="connect-btn">连接WebSocket</button>
            <button id="startBtn" class="start-btn" disabled>开始录音</button>
            <button id="stopBtn" class="stop-btn" disabled>停止录音</button>
        </div>
        
        <div id="status" class="status disconnected">未连接</div>
        
        <div class="results">
            <h3>识别结果:</h3>
            <div id="results"></div>
        </div>

        <div class="ai">
            <h3>AI回复:</h3>
            <div id="aiStatus" class="status"></div>
            <div id="aiTitle" class="result-item"></div>
            <div id="aiContent" class="log"></div>
        </div>
        
        <div>
            <h3>日志:</h3>
            <div id="log" class="log"></div>
        </div>
    </div>

    <script>
        let websocket = null;
        let audioContext = null;
        let sourceNode = null;
        let processorNode = null;
        let audioStream = null;
        let isRecording = false;
        const TARGET_SAMPLE_RATE = 16000;
        
        // ====== 实时合成音频播放相关 ======
        let playbackAudioContext = null;
        let playbackProcessorNode = null;
        let playbackQueue = [];
        let playbackStarted = false;
        let playbackDrainTimer = null;
        const PLAYBACK_SAMPLE_RATE = 24000; // 与服务端PCM_24000HZ_MONO_16BIT一致
        const PREBUFFER_SAMPLES = PLAYBACK_SAMPLE_RATE * 0.5; // 增加预缓存到500ms，提高稳定性
        const MAX_BUFFER_SAMPLES = PLAYBACK_SAMPLE_RATE * 1.5; // 减少最大缓冲到1.5秒，降低延时
        const MIN_BUFFER_SAMPLES = PLAYBACK_SAMPLE_RATE * 0.1; // 最小缓冲100ms，防止断音
        let currentStreamId = null;
        let expectedSeq = 1;
        const reorderBuffer = new Map();
        let drainMode = false;
        let finishMode = false;

        // 解析服务端WebSocket二进制音频帧（24字节头: 4 magic + 8 streamId + 4 seq + 8 timestamp）
        function parseAudioFrame(arrayBuffer) {
            try {
                const view = new DataView(arrayBuffer);
                if (view.byteLength < 24) return null;
                const m0 = view.getUint8(0), m1 = view.getUint8(1), m2 = view.getUint8(2), m3 = view.getUint8(3);
                if (!(m0 === 0x41 && m1 === 0x55 && m2 === 0x44 && m3 === 0x31)) {
                    return null; // 非音频帧
                }
                const streamIdHigh = view.getUint32(4, false);
                const streamIdLow = view.getUint32(8, false);
                const streamId = streamIdHigh * 2**32 + streamIdLow;
                const seq = view.getUint32(12, false);
                const pcmOffset = 24; // 跳过头部
                const pcmLen = view.byteLength - pcmOffset;
                const pcmBytes = new Uint8Array(arrayBuffer, pcmOffset, pcmLen);
                return { streamId, seq, pcm: pcmBytes };
            } catch (e) {
                addLog('解析音频帧失败: ' + e.message);
                return null;
            }
        }

        const connectBtn = document.getElementById('connectBtn');
        const startBtn = document.getElementById('startBtn');
        const stopBtn = document.getElementById('stopBtn');
        const status = document.getElementById('status');
        const results = document.getElementById('results');
        const log = document.getElementById('log');

        // 连接WebSocket
        connectBtn.addEventListener('click', () => {
            if (websocket && websocket.readyState === WebSocket.OPEN) {
                websocket.close();
                return;
            }

            // 使用当前页面的协议/主机/端口，避免端口不一致
            const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
            const wsUrl = `${wsProtocol}//${window.location.host}/ws/speech-recognition`;
            websocket = new WebSocket(wsUrl);
            websocket.binaryType = 'arraybuffer';

            websocket.onopen = () => {
                updateStatus('已连接', true);
                connectBtn.textContent = '断开连接';
                startBtn.disabled = false;
                addLog('WebSocket连接成功');
            };

            websocket.onmessage = (event) => {
                try {
                    if (event.data instanceof ArrayBuffer) {
                        const frame = parseAudioFrame(event.data);
                        if (!frame) return;
                        // 如果尚未锁定当前流，则以首个到达的二进制帧作为当前流并初始化播放
                        if (currentStreamId === null) {
                            currentStreamId = frame.streamId;
                            drainMode = false;
                            finishMode = false;
                            if (!playbackAudioContext || !playbackProcessorNode) {
                                initPlayback();
                            }
                            addLog(`检测到新音频帧，锁定streamId=${currentStreamId}`);
                        }
                        // 仅播放当前stream
                        if (currentStreamId !== null && frame.streamId !== currentStreamId) {
                            return;
                        }
                        // 重排序：缓存到达的序号片段
                        if (frame.seq < expectedSeq) {
                            // 已过期的片段，忽略
                            return;
                        }
                        reorderBuffer.set(frame.seq, frame.pcm);
                        // 依次出队到播放队列（严格递增）
                        while (reorderBuffer.has(expectedSeq)) {
                            const pcm = reorderBuffer.get(expectedSeq);
                            reorderBuffer.delete(expectedSeq);
                            const float32 = pcm16ToFloat32(pcm);
                            playbackQueue.push(float32);
                            expectedSeq++;
                        }
                        // 在finish模式下，如果存在序号空洞导致无法继续，但仍有数据，允许跳到下一个可用序号以避免尾部被吞
                        if (finishMode && reorderBuffer.size > 0 && !reorderBuffer.has(expectedSeq)) {
                            const keys = Array.from(reorderBuffer.keys());
                            const minSeq = Math.min.apply(null, keys.filter(k => k > expectedSeq));
                            if (Number.isFinite(minSeq)) {
                                const pcm = reorderBuffer.get(minSeq);
                                reorderBuffer.delete(minSeq);
                                const float32 = pcm16ToFloat32(pcm);
                                playbackQueue.push(float32);
                                expectedSeq = minSeq + 1;
                            }
                        }
                    } else {
                        const message = JSON.parse(event.data);
                        handleMessage(message);
                    }
                } catch (e) {
                    addLog('解析消息失败: ' + e);
                }
            };

            websocket.onclose = () => {
                updateStatus('已断开', false);
                connectBtn.textContent = '连接WebSocket';
                startBtn.disabled = true;
                stopBtn.disabled = true;
                addLog('WebSocket连接关闭');
            };

            websocket.onerror = (error) => {
                addLog('WebSocket错误: ' + error);
            };
        });

        // 开始录音（发送16k单声道PCM）
        startBtn.addEventListener('click', async () => {
            try {
                audioStream = await navigator.mediaDevices.getUserMedia({ 
                    audio: {
                        channelCount: 1,
                        echoCancellation: true,
                        noiseSuppression: true
                    } 
                });

                const AudioContextCls = window.AudioContext || window.webkitAudioContext;
                // 请求16k采样率（某些浏览器可能忽略，但我们会在后续自行重采样）
                audioContext = new AudioContextCls({ sampleRate: TARGET_SAMPLE_RATE });
                sourceNode = audioContext.createMediaStreamSource(audioStream);
                processorNode = audioContext.createScriptProcessor(4096, 1, 1);

                processorNode.onaudioprocess = (event) => {
                    if (!(websocket && websocket.readyState === WebSocket.OPEN && isRecording)) return;
                    const input = event.inputBuffer.getChannelData(0);
                    const inRate = audioContext.sampleRate;
                    const resampled = resampleToTarget(input, inRate, TARGET_SAMPLE_RATE);
                    const pcm = floatTo16BitPCM(resampled);
                    websocket.send(pcm.buffer);
                };

                sourceNode.connect(processorNode);
                processorNode.connect(audioContext.destination);

                isRecording = true;
                startBtn.disabled = true;
                stopBtn.disabled = false;

                // 发送开始录音控制消息
                sendControlMessage('start');
                addLog('开始录音');

            } catch (error) {
                addLog('获取麦克风权限失败: ' + error.message);
            }
        });

        // 停止录音
        stopBtn.addEventListener('click', () => {
            if (isRecording) {
                try {
                    if (processorNode) {
                        processorNode.disconnect();
                        processorNode.onaudioprocess = null;
                        processorNode = null;
                    }
                    if (sourceNode) {
                        sourceNode.disconnect();
                        sourceNode = null;
                    }
                    if (audioContext) {
                        audioContext.close();
                        audioContext = null;
                    }
                    if (audioStream) {
                        audioStream.getTracks().forEach(track => track.stop());
                        audioStream = null;
                    }
                } catch (e) {
                    addLog('停止录音时发生错误: ' + e.message);
                }

                isRecording = false;
                startBtn.disabled = false;
                stopBtn.disabled = true;
                
                // 发送停止录音控制消息
                sendControlMessage('stop');
                addLog('停止录音');
            }
        });

        // 发送控制消息
        function sendControlMessage(action) {
            if (websocket && websocket.readyState === WebSocket.OPEN) {
                const message = {
                    action: action,
                    data: {}
                };
                websocket.send(JSON.stringify(message));
            }
        }

        // 处理服务器消息
        function handleMessage(message) {
            addLog('收到消息: ' + JSON.stringify(message));
            
            if (message.type === 'recognition') {
                addResult(message.text, message.isFinal, message.timestamp);
            } else if (message.type === 'ai') {
                handleAiMessage(message);
            } else if (message.type === 'audio') {
                handleAudioMessage(message);
            } else if (message.type === 'error') {
                addLog('错误: ' + message.text);
            }
        }

        function handleAiMessage(msg) {
            const sub = msg.subType;
            if (sub === 'ready') {
                document.getElementById('aiStatus').textContent = 'AI已准备输出';
                document.getElementById('aiStatus').className = 'status connected';
                document.getElementById('aiContent').textContent = '';
            } else if (sub === 'title') {
                document.getElementById('aiTitle').textContent = msg.text || '';
            } else if (sub === 'chunk') {
                const el = document.getElementById('aiContent');
                el.textContent += msg.text || '';
                el.scrollTop = el.scrollHeight;
            } else if (sub === 'finish') {
                document.getElementById('aiStatus').textContent = 'AI已完成输出';
                document.getElementById('aiStatus').className = 'status connected';
            }
        }

        // ====== 处理音频消息并播放 ======
        function handleAudioMessage(msg) {
            const sub = msg.subType;
            const msgStreamId = msg.streamId ?? null;
            if (sub === 'ready') {
                // 切换到新的流：仅当新旧streamId不同才停止旧播放并重置
                const newId = msgStreamId;
                if (currentStreamId !== null && newId !== null && currentStreamId !== newId) {
                    stopPlayback();
                    addLog('停止之前的播放，准备新的TTS音频');
                    if (playbackDrainTimer) { clearInterval(playbackDrainTimer); playbackDrainTimer = null; }
                    drainMode = false;
                    finishMode = false;
                    expectedSeq = 1;
                    reorderBuffer.clear();
                    currentStreamId = newId;
                    initPlayback();
                    addLog(`TTS音频合成已准备，streamId=${currentStreamId}`);
                } else {
                    // 同一流或尚未锁定，避免清空已到达的首音
                    if (currentStreamId === null && newId !== null) {
                        currentStreamId = newId;
                        drainMode = false;
                        finishMode = false;
                        expectedSeq = 1;
                        initPlayback();
                    }
                    addLog(`TTS音频合成已准备，streamId=${currentStreamId}`);
                }
            } else if (sub === 'delta') {
                // 兼容旧的base64文本帧
                if (playbackAudioContext && playbackProcessorNode) {
                    enqueueBase64Pcm(msg.text || '');
                }
            } else if (sub === 'audio_done') {
                addLog('当前响应音频已生成完毕');
            } else if (sub === 'response_done') {
                addLog('当前响应完全完成');
            } else if (sub === 'finish') {
                addLog('TTS音频合成完成，等待缓冲区排空');
                drainMode = true;
                finishMode = true;
                // 启动排空监测，缓冲区为空后再真正停止
                if (playbackDrainTimer) clearInterval(playbackDrainTimer);
                playbackDrainTimer = setInterval(() => {
                    // 将重排序缓冲中的片段尽可能推进到播放队列，避免尾部因序号空洞而被吞
                    let progressed = false;
                    // 先推进严格连续的序列
                    while (reorderBuffer.has(expectedSeq)) {
                        const pcm = reorderBuffer.get(expectedSeq);
                        reorderBuffer.delete(expectedSeq);
                        const float32 = pcm16ToFloat32(pcm);
                        playbackQueue.push(float32);
                        expectedSeq++;
                        progressed = true;
                    }
                    // 在完成模式下，如果仍存在数据但被序号空洞阻塞，允许跳到下一个可用序号
                    if (finishMode && reorderBuffer.size > 0 && !reorderBuffer.has(expectedSeq)) {
                        const nextSeqs = Array.from(reorderBuffer.keys()).filter(k => k > expectedSeq);
                        if (nextSeqs.length > 0) {
                            const minSeq = Math.min(...nextSeqs);
                            const pcm = reorderBuffer.get(minSeq);
                            reorderBuffer.delete(minSeq);
                            const float32 = pcm16ToFloat32(pcm);
                            playbackQueue.push(float32);
                            expectedSeq = minSeq + 1;
                            progressed = true;
                        }
                    }
                
                    const bufferedSamples = playbackQueue.reduce((sum, arr) => sum + arr.length, 0);
                    // 当播放队列为空且重排序缓冲也为空时，才真正停止播放，避免尾音被提前清理
                    if (bufferedSamples === 0 && reorderBuffer.size === 0) {
                        clearInterval(playbackDrainTimer);
                        playbackDrainTimer = null;
                        stopPlayback();
                        addLog('播放已结束');
                        currentStreamId = null;
                        expectedSeq = 1;
                        reorderBuffer.clear();
                    }
                }, 50);
            }
        }

        function initPlayback() {
            try {
                const AudioContextCls = window.AudioContext || window.webkitAudioContext;
                // 尝试请求24k采样率（部分浏览器可能忽略）
                playbackAudioContext = new AudioContextCls({ sampleRate: PLAYBACK_SAMPLE_RATE });
                // 仅输出，无输入声道
                playbackProcessorNode = playbackAudioContext.createScriptProcessor(4096, 0, 1);
                playbackProcessorNode.onaudioprocess = (event) => {
                    const out = event.outputBuffer.getChannelData(0);
                    let offset = 0;
                    
                    while (offset < out.length) {
                        const bufferedSamples = playbackQueue.reduce((sum, arr) => sum + arr.length, 0);
                        
                        // 排空模式下，不做预缓冲或暂停判断，强制消耗队列
                        if (drainMode && !playbackStarted) {
                            playbackStarted = true;
                        }
                        
                        if (!drainMode) {
                            // 动态缓冲控制：未达到预缓存阈值前输出静音
                            if (!playbackStarted && bufferedSamples < PREBUFFER_SAMPLES) {
                                for (let i = offset; i < out.length; i++) out[i] = 0;
                                break;
                            } else if (!playbackStarted) {
                                playbackStarted = true;
                                addLog(`开始播放，缓冲: ${(bufferedSamples/PLAYBACK_SAMPLE_RATE*1000).toFixed(0)}ms`);
                            }
                        
                            // 如果缓冲过低且已开始播放，暂停播放等待更多数据
                            if (playbackStarted && bufferedSamples < MIN_BUFFER_SAMPLES && playbackQueue.length > 0) {
                                playbackStarted = false;
                                addLog('缓冲不足，暂停播放等待数据');
                                for (let i = offset; i < out.length; i++) out[i] = 0;
                                break;
                            }
                        }
                        
                        if (playbackQueue.length === 0) {
                            // 缺少数据时填充静音
                            for (let i = offset; i < out.length; i++) out[i] = 0;
                            break;
                        }
                        
                        let chunk = playbackQueue[0];
                        // 如输出采样率与源不一致，做重采样
                        const outRate = playbackAudioContext.sampleRate;
                        if (outRate !== PLAYBACK_SAMPLE_RATE) {
                            chunk = resampleToTarget(chunk, PLAYBACK_SAMPLE_RATE, outRate);
                        }
                        
                        const need = out.length - offset;
                        const copyCount = Math.min(need, chunk.length);
                        out.set(chunk.subarray(0, copyCount), offset);
                        offset += copyCount;
                        
                        if (copyCount < chunk.length) {
                            playbackQueue[0] = chunk.subarray(copyCount);
                        } else {
                            playbackQueue.shift();
                        }
                    }
                };
                playbackProcessorNode.connect(playbackAudioContext.destination);
                playbackStarted = false;
                addLog('播放管线已初始化');
            } catch (e) {
                addLog('初始化播放失败: ' + e.message);
            }
        }

        function stopPlayback() {
            try {
                if (playbackProcessorNode) {
                    playbackProcessorNode.disconnect();
                    playbackProcessorNode.onaudioprocess = null;
                    playbackProcessorNode = null;
                }
                if (playbackAudioContext) {
                    playbackAudioContext.close();
                    playbackAudioContext = null;
                }
            } catch (e) {
                addLog('停止播放时发生错误: ' + e.message);
            }
            if (playbackDrainTimer) { clearInterval(playbackDrainTimer); playbackDrainTimer = null; }
            drainMode = false;
            finishMode = false;
            playbackQueue = [];
            playbackStarted = false;
        }

        function enqueueBase64Pcm(base64) {
            if (!base64) return;
            try {
                const bytes = base64ToUint8Array(base64);
                const float32 = pcm16ToFloat32(bytes);
                playbackQueue.push(float32);
                // 防止队列无限增长，丢弃过多历史片段，保持低延时
                const bufferedSamples = playbackQueue.reduce((sum, arr) => sum + arr.length, 0);
                if (bufferedSamples > MAX_BUFFER_SAMPLES) {
                    // 丢弃最旧的部分数据，直到降到目标范围
                    let toDrop = bufferedSamples - MAX_BUFFER_SAMPLES;
                    while (toDrop > 0 && playbackQueue.length > 0) {
                        const first = playbackQueue[0];
                        if (first.length <= toDrop) {
                            toDrop -= first.length;
                            playbackQueue.shift();
                        } else {
                            playbackQueue[0] = first.subarray(toDrop);
                            toDrop = 0;
                        }
                    }
                }
            } catch (e) {
                addLog('解码音频片段失败: ' + e.message);
            }
        }

        function base64ToUint8Array(base64) {
            const binary = atob(base64);
            const len = binary.length;
            const bytes = new Uint8Array(len);
            for (let i = 0; i < len; i++) {
                bytes[i] = binary.charCodeAt(i);
            }
            return bytes;
        }

        function pcm16ToFloat32(u8) {
            const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
            const samples = u8.byteLength / 2;
            const out = new Float32Array(samples);
            for (let i = 0; i < samples; i++) {
                const s = dv.getInt16(i * 2, true);
                out[i] = s / 0x8000; // [-1,1]
            }
            return out;
        }

        // 更新连接状态
        function updateStatus(text, connected) {
            status.textContent = text;
            status.className = connected ? 'status connected' : 'status disconnected';
        }

        // 添加识别结果
        function addResult(text, isFinal, timestamp) {
            const resultDiv = document.createElement('div');
            resultDiv.className = 'result-item' + (isFinal ? ' final' : '');
            
            const time = new Date(timestamp).toLocaleTimeString();
            resultDiv.innerHTML = `
                <div>${text}</div>
                <div class="timestamp">${time} ${isFinal ? '(最终结果)' : '(中间结果)'} </div>
            `;
            
            results.appendChild(resultDiv);
            results.scrollTop = results.scrollHeight;
        }

        // 添加日志
        function addLog(message) {
            const time = new Date().toLocaleTimeString();
            log.innerHTML += `[${time}] ${message}\n`;
            log.scrollTop = log.scrollHeight;
        }

        // 简单重采样到目标采样率（线性采样）
        function resampleToTarget(inputFloat32, inSampleRate, outSampleRate) {
            if (inSampleRate === outSampleRate) return inputFloat32;
            const ratio = inSampleRate / outSampleRate;
            const newLength = Math.floor(inputFloat32.length / ratio);
            const result = new Float32Array(newLength);
            let idx = 0;
            for (let i = 0; i < newLength; i++) {
                const srcIndex = Math.floor(i * ratio);
                result[i] = inputFloat32[srcIndex] || 0;
            }
            return result;
        }

        // Float32 [-1,1] 转 16-bit PCM Little Endian
        function floatTo16BitPCM(float32Array) {
            const buffer = new ArrayBuffer(float32Array.length * 2);
            const view = new DataView(buffer);
            let offset = 0;
            for (let i = 0; i < float32Array.length; i++, offset += 2) {
                let s = Math.max(-1, Math.min(1, float32Array[i]));
                view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
            }
            return new Uint8Array(buffer);
        }

        // 页面加载完成后的初始化
        window.addEventListener('load', () => {
            addLog('页面加载完成，请点击"连接WebSocket"开始');
        });
    </script>
</body>
</html>